/* App State */
class AppState {
	constructor() {
		this.version = null;
		try {
			const manifest = chrome.runtime.getManifest();
			this.version = manifest?.version;
		} catch (e) {
			console.error(e);
		}
		this.state = { // persisted state
			settings: {}, // Extension specific settings, User settings are managed in web app.
			thread_sort_mode: 'date_desc', // default to newest sort mode
			last_channel_id: null, // tracks what channel the user was last "in"
			highest_thread_id: 0 // tracks the highest thead id rendered in the app (seen by user). Used for channel mini-notifications.
		};

		// settings
		this.settingsDefault = {
			server_url: "https://solfray.com", // fallback server url
			server_api_key: '',
			font_size: '0.7em',
		};
		this.settingsSchema = {
			server_url: 'string',
			server_api_key: 'string',
			font_size: 'string',
		};
		this.settingsDescriptions = {
			server_url: 'The URL of the server to send chats to.',
			server_api_key: 'Your API key for interfacing with the server.',
			font_size: 'General font size of extension text. Some elements may be larger or smaller.',
		};
		for (let key in this.settingsDefault) this.settingsSchema[key] = typeof this.settingsDefault[key];
		this.settingsSchema.server_url = 'string';

		// other session state variables (not persisted)
		this.publicKey = null; // fetched from server using api key
		this.slug = null;
		this.lockedThreadId = null; // set to id if locked
		this.currentURL = null; // The current URL being viewed by the user.
		this.unlockUponReturn = null; // After tipping from extension, unlock thread upon return to that tab.
		this.myChannels = null; // Fetched from /auth/me endpoint
		this.userFiat = null; // Fetched from /auth/me endpoint
		this.subscribedTo = null;
		this.channelThreadsCache = null;
		this.loadState();

		// Uses class CUN2 from thread.js
		this.threadApp = null; // set dynamically when a thread is loaded
	}

	getCurrentURL() {
		return this.currentURL || null;
	}

	setCurrentURL(url) {
		if (typeof url == 'string' && url.length > 0 && url.toLowerCase().startsWith('https://')) {
			this.currentURL = url;
			// If the thread is locked, do not re-render
			if(this.lockedThreadId){
				if(this.unlockUponReturn && this.unlockUponReturn === url){ // Auto-unlock after tipping.
					setTimeout(function(){
						$('#thread_locker').trigger('click');
					},3000);
				}
				return this.currentURL;
			}
			$('#thread-container').empty();
			// get site base url for tree opener. Remove all queries and paths.
			const urlObj = new URL(url);
			const baseURL = `${urlObj.protocol}//${urlObj.hostname}`;
			const colDiv = $(`<div class="col-md-8 offset-md-2"></div>`);
			const treeOpener = $(`<a href="#" class="tree_opener" data-base-url="${baseURL}" title="List all URLs with threads at this site."><span class="bi bi-diagram-2"></span></a>`);
			treeOpener.on('click', (e) => {
				$('.gui_blur').removeClass('gui_blur');
				$('#channel_thread_container').removeClass('showing').css({display:'none'});
				$('#thread_channel_selector_container').removeClass('showing').css({display:'none'});
				if($('#close_tree').length){
					$('#close_tree').trigger('click');
					return;
				}
				const siteBaseURL = $(e.currentTarget).data('base-url') || null;
				const serverURL = this.getSetting('server_url') || null;
				const slash = serverURL.endsWith('/') ? '' : '/';
				const apiKey = this.getSetting('server_api_key') || null;
				if(!serverURL || typeof serverURL != 'string' || serverURL.length < 1){
					this.feed("Set Server URL in extension settings.", true);
					return;
				}
				if(!apiKey || typeof apiKey != 'string' || apiKey.length < 1){
					this.preKeySplash(serverURL);
					return;
				}
				$('#reply-container').empty().append('<span>Loading site thread tree...</span>');
				fetch(`${serverURL}${slash}api/site_tree?base_url=${encodeURIComponent(siteBaseURL)}`, {
					headers: {
						'Authorization': `Bearer ${apiKey}`
					}
				}).then(
					response => response.json()
				).then(data => {
					if (data && data.ok) {
						const closeIcon = bsi('x-lg') || '❌';
						const closeLink = $(`<a href="#" class="text-muted float-end" id="close_tree">${closeIcon} Close</a>`);
						closeLink.on('click', e => {
							e.preventDefault();
							$('#reply-container').empty();
							this.getThreads();
						});
						const treeSearch = $(`
							<input type="text" id="tree_search_input" class="form-control" placeholder="Search URLs..." title="Type to filter URLs in the table below.">
						`);
						treeSearch.on('input', (e) => {
							const searchTerm = $(e.currentTarget).val().toLowerCase();
							if (!searchTerm || searchTerm.length < 1) {
								// show all rows
								$('#tree_table tbody tr').show();
								return;
							}
							$('#tree_table tbody tr').each((i, row) => {
								var urlCell = $(row).find('td:nth-child(2)').text().toLowerCase();
								// replace all dashes, underscores, and spaces with single space for matching
								urlCell = urlCell.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');
								if (urlCell.indexOf(searchTerm) > -1) {
									$(row).show();
								} else {
									$(row).hide();
								}
							});
						});
						const treeTable = $(`
							<table id="tree_table" class="table table-sm table-bordered">
								<thead>
									<tr>
										<th title="Thread Count">No.</th>
										<th title="Page URL">Page URL</th>
									</tr>
								</thead>
								<tbody></tbody>
							</table>
						`);
						if (Array.isArray(data.thread_counts) && data.thread_counts.length > 0) {
							for (let i = 0; i < data.thread_counts.length; i++) {
								const row = data.thread_counts[i];
								const tr = $(`
									<tr>
										<td title="Number of threads at this URL" style="text-align: center; vertical-align: middle;">${row.thread_count || 0}</td>
										<td><a href="${row.url || ''}" target="_blank">${row.url || ''}</a></td>
									</tr>
								`);
								treeTable.find('tbody').append(tr);
							}
						} else {
							treeTable.find('tbody').append('<tr><td colspan="2">No threads found at this site.</td></tr>');
						}
						const fadeDiv = $('<div style="display:none;"></div>');
						fadeDiv.append(closeLink, '<br><br>', treeSearch, '<br>', treeTable);
						$('#reply-container').empty().append(fadeDiv);
						fadeDiv.fadeIn(300,()=>{
							$('#tree_search_input').focus();
						});
					}
				}).catch(err => {
					console.error(err);
					this.feed("Error loading site thread tree.", true);
				});
			});
			colDiv.append(treeOpener, `&nbsp;<span class="text-muted small url-viewer" title="${this.currentURL}">${this.getShortURL()}</span>`);
			$('#thread-container').append(colDiv);
			setTimeout(render_bsi, 10);
		}
		return this.currentURL;
	}

	feed(arg, err = false) {
		if (err){
			console.trace('ERROR: ',arg);	// for debugging
			$('#feed_error').removeClass('d-none');
		}else{
			$('#feed_error').addClass('d-none');
		}
		$('#feed').empty().append((arg.toString() || "&nbsp;"));
		$('#feed_container').attr('title',arg);
	}

	preKeySplash(serverURL = null) {
		console.log('Displaying pre-key splash screen.', serverURL);
		// The user doesn't have an API key yet. Welcome them and prompt to set one.
		$('#reply-container').empty().append(`
			<div class="alert alert-info">
				<img src="images/icon-48.png" alt="SolFray Logo" style="height:4em;width:4em;display:block;">
				<h4>Welcome to the SolFray Thread Extension!</h4>
				<p>To get started, please set your API key in the extension settings.</p>
				<p>Get an API key at <a href="${serverURL}/api-keys" target="_blank">${serverURL.replace('https://','')}/api-keys</a>. No login required. Just connect your Solana wallet.</p>
				<p>After getting your API key, click the settings icon (<span class="bi bi-gear"></span>) in the upper right hand corner of the extension and enter your key.</p>
				<p>Once set, you'll be able to create and reply to threads directly from this extension!</p>
			</div>
		`);
	}

	lockThread() {
		// Will prevent re-render on user navigation in browser.
		this.lockedThreadId = (this.threadApp && typeof this.threadApp == 'object')? (this.threadApp?.thread_id || null): null;
		return this.lockedThreadId ? true : false;
	}

	unlockThread() {
		const wasLocked = this.lockedThreadId ? true : false;
		// Will allow re-render on user navigation in browser.
		this.lockedThreadId = null;
		this.unlockUponReturn = null;
		return false;
	}

	toggleThreadLock() {
		if (this.lockedThreadId) return this.unlockThread();
		return this.lockThread();
	}

	getShortURL() {
		const url_len = 22;
		var url = this.currentURL || null;
		if (!url || typeof url !== 'string') return null;
		url = url.replace(/^https:\/\/(www\.)?/, '');
		var shortUrl = url.substring(0, url_len);
		return url.length > url_len ? shortUrl + "..." : url + "";
	}

	primeThread(chatId, replyToChatId = null, exitChatId = null) {
		console.log('not implemented yet');
		return;
	}

	emptyContainers() {
		// clean slate
		$('#thread-container').empty();
		$('#reply-container').empty();
		$('#compose_container').empty();
		$('.gui_blur').removeClass('gui_blur');
		$('#channel_thread_container').removeClass('showing').css({display:'none'});
		$('#thread_channel_selector_container').removeClass('showing').css({display:'none'});
	}

	unloadThread() {
		// destroy existing threadApp if exists
		if (this.threadApp && typeof this.threadApp == 'object') {
			this.threadApp.destroy();
			this.threadApp = null;
		}
		$('#thread-container').removeClass('thread');
		// render short url display
		this.setCurrentURL(this.getCurrentURL()); 
	}

	loadThread(chatId, authorPK = null) {
		$("#thread_id").val(chatId);
		this.emptyContainers();
		this.unloadThread();
		$('#thread-container').addClass('thread'); // required by render_threads().
		this.threadApp = new CUN2(chatId, authorPK, this.publicKey, this.getSetting('server_url'));
		this.threadApp.start((e) => { // form submit handler
			e.preventDefault();
			// serialize form
			const formDataArray = $(e.target).serializeArray();
			const dict = {};
			formDataArray.forEach(item => {
				dict[item.name] = item.value;
			});
			// Empty the form inputs
			this.threadApp.clear_composer_form();
			// Validate and process form data
			const lamports = parseInt(dict?.['tip_amount_lamports'] || '0') || 0;
			const serverURL = this.getSetting('server_url') || null;
			if (!serverURL || typeof serverURL != 'string' || serverURL.length < 1) {
				alert("Set Server URL in extension settings.");
				return;
			}
			const apiKey = this.getSetting('server_api_key') || null;
			if (!apiKey || typeof apiKey != 'string' || apiKey.length < 1) {
				this.preKeySplash(serverURL);
				return;
			}
			const slash = serverURL.endsWith('/') ? '' : '/';
			dict.top_chat_id = $("#thread_id").val();
			if(lamports > 0){
				// Lock thread if not locked before submission
				if (!this.lockedThreadId) {
					this.unlockUponReturn = this.getCurrentURL();
					this.lockThread();
					$('#thread_locker').removeClass('unlocked').empty().append('<span class="bi bi-unlock"></span>&nbsp;Unlock');
					render_bsi();			
				} // else, user locked thread manually, do not auto-unlock
				// POST the dict to /api_tip_target for user to sign and send tip in web app.
				const endpoint = `${serverURL}${slash}api_tip_target`;
				var formInputs = '';
				for(const key in dict){
					formInputs += `<input type="hidden" name="${key}" value="${dict[key]}">`;
				}
				const postForm = $(`
					<form method="post" action="${endpoint}" target="_blank">
						<input type="hidden" name="api_key" value="${apiKey}">
						${formInputs}
					</form>
				`);
				// Submit form
				$('body').append(postForm);
				postForm.submit();
				postForm.remove();
				return;
			}else{ // Users can send messages without tip via extension
				const endpoint = `${serverURL}${slash}api/chats/create`;
                $.ajax({
                    url: endpoint,
                    method: "POST",
					headers: {
						'Content-Type': 'application/json',
						'Authorization': `Bearer ${apiKey}`
					},
                    data: JSON.stringify(dict),
                    contentType: "application/json",
                    success: (data) => {
                        if (data.ok) {
                            this.threadApp.build_composer_form();
                            this.threadApp.load_replies();
                        } else {
                            alert("Failed to post reply: " + data.error?.message || "Unknown error");
                        }
                    },
                    error: (xhr, status, error) => {
                        console.error("Failed to post reply:", xhr, status, error);
                        this.threadApp.end_session();
                    }
                });
			}
		});
		this.threadApp.build_composer_form();
		$('#thread_locker').css({display:'inline-block'});
	}

	buildCreateThreadForm(clearValue = true){
		var pluginVal = $('#thread_content').length? $('#thread_content').val(): "";
		pluginVal = clearValue? "": pluginVal;
		const url = this.getCurrentURL();
		const availableChannels = (Array.isArray(this.myChannels) && this.myChannels.length > 0)? JSON.parse(JSON.stringify(this.myChannels)): [];
		// Subscriptions can repeat depending on types of memeberships, so filter
		var alreadySubbed = [];
		var alreadySubbedChannelIDs = [];
		if(this.subscribedTo && Array.isArray(this.subscribedTo)){
			for(var i=0; i<this.subscribedTo.length; i++){
				const subTo = this.subscribedTo[i];
				try{
					const subChannels = subTo?.channels || [];
					const subUserInfo = subTo?.user_info || {};
					const subbedToPK = subUserInfo?.public_key || null;
					if (!subbedToPK || alreadySubbed.indexOf(subbedToPK) > -1){
						continue;
					}
					for(var ii=0; ii<subChannels.length; ii++){
						var chan = JSON.parse(JSON.stringify(subChannels[ii]));
						const chan_id = chan?.channel_id || null;
						if(!chan_id || alreadySubbedChannelIDs.indexOf(chan_id) > -1){
							continue;
						}
						alreadySubbedChannelIDs.push(chan_id);
						chan.subscribed_to_name = subUserInfo?.display_name || '[Unknown-Name]';
						chan.subscribed_to_img = subUserInfo?.profile_picture_url || null;
						chan.owner_public_key = subUserInfo?.public_key || null;
						chan.slug = subUserInfo?.slug || null;
						availableChannels.push(chan);
					}
				}catch(e){
					console.error(e);
				}
			}
		}
		const serverURL = this.getSetting('server_url');
		var channelBtnDiv = '';
		var previousChanOwner = 'My Server';
		var textMutedClass = (this.state?.last_channel_id && this.state?.last_channel_id*1)? 'text-muted': 'reverse-text-gradient' 
		if(availableChannels.length > 0){ // Allow user to select a channel
			try{
				var slug = this?.slug || this?.publicKey || null;
				var profileURL = (serverURL && slug)? `${serverURL}/profile/${slug}`: null;
				var profileLink = profileURL? `<a href="${profileURL}" class="text-muted small float-end" target="_blank"><span class="bi bi-box-arrow-up-right"></span></a>`: '';
				channelBtnDiv += `<div id="thread_channel_selector_container">
									<span class="reverse-text-gradient small">Channels</span>
									<a class="text-muted small float-end close_thread_channel_selector_container"><span class="bi bi-x-lg"></span>&nbsp;close</a>
									<div id="channel_selector_options">
										<input type="text" id="channel_title_search_input" class="form-control" placeholder="Search channels..." style="margin-top:4px;">
										<span id="my_server_channels_img"></span>&nbsp;My Server
										<div class="channel_switcher_container">
											<span style="display:none;">${previousChanOwner}</span>
											<a class="channel_switcher ${textMutedClass}" data-channel-id="0">Home Page</a>
											${profileLink}
										</div>`;
				var channelBtn = `<a class="btn btn-sm btn-secondary form-control channel_selector"><span class="small">My Server</span><br>Home</a>`;
				for(var i=0; i<availableChannels.length; i++){

					// parse channel
					var chan = availableChannels[i];
					if(!chan || !chan?.channel_id) continue;
					const subscribed_to_name = chan?.subscribed_to_name || 'My Server';
					var channel_type = chan?.channel_type? `(${chan.channel_type})` : '';
					channel_type = channel_type.toLowerCase() == '(normal)'? '': channel_type;
					var subscribed_to_img = chan?.subscribed_to_img || null;
					const char_circle = chan.owner_public_key == this.publicKey? '': `<div class="char_circle">${subscribed_to_name.substring(0,1)}</div>&nbsp;`;
					subscribed_to_img = subscribed_to_img? `<img src="${subscribed_to_img}" style="display:inline-block;height:2em;border-radius:50%;">&nbsp;`: char_circle.replace(' class="char_circle"',' class="char_circle_small"'); 
					if(subscribed_to_name && subscribed_to_name != previousChanOwner){
						slug = chan?.slug || chan?.owner_public_key || null;
						profileURL = (serverURL && slug)? `${serverURL}/profile/${slug}`: null;
						profileLink = profileURL? `&nbsp;&nbsp;<a href="${profileURL}" class="text-muted small" target="_blank"><span class="bi bi-box-arrow-up-right"></span></a>`: '';
						channelBtnDiv += `<br>${subscribed_to_img}${subscribed_to_name}${profileLink}`;
					}
					previousChanOwner = subscribed_to_name;

					// set channelBtn
					textMutedClass = 'text-muted';
					if(chan.channel_id == this.state?.last_channel_id){
						channelBtn = `<a class="btn btn-secondary form-control channel-thread-indicator text-muted" id="channel-thread-indicator" data-name="${chan.name.replace(/"/g,'&quot')}">...<br>${bsi('bolt')}</a>
										<input type="hidden" id="thread_channel" name="channel_id" value="${chan.channel_id}">
										<a class="btn btn-sm btn-secondary form-control channel_selector">${subscribed_to_img}<span class="small">${subscribed_to_name}</span><br># ${chan.name} ${channel_type}</a>`;
						textMutedClass = 'reverse-text-gradient'; // selected
					}

					const uriChannel = encodeURIComponent(chan.name);
					const channelURL = (serverURL && uriChannel && profileURL)? profileURL + '?channel=' + uriChannel: null;
					const channelLink = channelURL? `<a href="${channelURL}" class="text-muted small float-end" target="_blank"><span class="bi bi-box-arrow-up-right"></span></a>`: '';

					// Add to options
					channelBtnDiv += `<div class="channel_switcher_container">
										<span style="display:none;">${subscribed_to_name}</span>
										<a class="channel_switcher ${textMutedClass}" data-channel-id="${chan.channel_id}"># ${chan.name} ${channel_type}</a>
										${channelLink}
									</div>`;
				}
				channelBtnDiv += `<br><br></div></div>`; // Close hidden dropdown.
				channelBtnDiv += '<div class="input-group">' + channelBtn + '</div>';
			}catch(e){
				console.error('Error building channel selector:', e);
			}
		}
		const createThreadForm = $(`
			<form id="create_thread_form" style="display:none;">
				<div id="channel_thread_container"></div>
				${channelBtnDiv}
				<div class="input-group" id="thread_form_input_group">
					<input type="text" class="form-control thread_content" id="thread_content" name="content" placeholder="Create Thread" required="required" maxlength="280" autocomplete="off" spellcheck="true" value="${pluginVal}">
					<input type="hidden" id="thread_url" name="url" value="${url}">
					<button id="trd_btn" type="submit" class="btn btn-primary form-control" title="Create New Thread">
						<span class="bi bi-plus-lg"></span>
					</button>
				</div>
			</form>`);
		createThreadForm.find('.close_thread_channel_selector_container').on('click',function(){
			$('.channel_selector').trigger('click');
		});
		createThreadForm.find('#channel_title_search_input').on('input',(e)=>{
				const searchTerm = $(e.currentTarget).val().toLowerCase().trim();
				if (!searchTerm || searchTerm.length < 1) {
					// show all threads
					$('.channel_switcher_container').show();
				} else {
					$('.channel_switcher_container').each((i, el) => {
						var txt = $(el).text().toLowerCase();
						// replace all dashes, underscores, and spaces with single space for matching
						txt = txt.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');
						if (txt.includes(searchTerm)) {
							$(el).show();
						} else {
							$(el).hide();
						}
					});
				}
		});
		createThreadForm.find('.channel_selector').on('click',(e)=>{
			e.preventDefault();
			$('#channel_thread_container').removeClass('showing').css({display:'none'});
			if($('#thread_channel_selector_container').hasClass('showing')){
				$('#thread_channel_selector_container').removeClass('showing').slideUp(300);
				$('.gui_blur').removeClass('gui_blur');
				return;
			}
			$('#thread_channel_selector_container').addClass('showing').css({display:'block',height:'1vh'}).animate({height:'60vh'},300,()=>{
				$('#channel_title_search_input').focus();
			});
			$('#reply-container').addClass('gui_blur');
		});
		createThreadForm.find('.channel_switcher').on('click',(e)=>{
			e.preventDefault();
			this.state.last_channel_id = $(e.currentTarget).attr('data-channel-id');
			this.saveState();
			$('.gui_blur').removeClass('gui_blur');
			this.buildCreateThreadForm();
		});
		createThreadForm.on('submit', (e) => {
			e.preventDefault();
			const formDataArray = $(e.target).serializeArray();
			const dict = {};
			formDataArray.forEach(item => {
				dict[item.name] = item.value;
			});
			const serverURL = this.getSetting('server_url') || null;
			if (!serverURL || typeof serverURL != 'string' || serverURL.length < 1) {
				this.feed("Set Server URL in extension settings.", true);
				return;
			}
			const apiKey = this.getSetting('server_api_key') || null;
			if (!apiKey || typeof apiKey != 'string' || apiKey.length < 1) {
				this.preKeySplash(serverURL);
				return;
			}
			const slash = serverURL.endsWith('/') ? '' : '/';
			const endpoint = `${serverURL}${slash}api/chats/create`;
			$('#thread_content').val('');
			$.ajax({
				url: endpoint,
				method: "POST",
				headers: {
					'Content-Type': 'application/json',
					'Authorization': `Bearer ${apiKey}`
				},
				data: JSON.stringify(dict),
				contentType: "application/json",
				success: (data) => {
					if (data.ok) {
						this.feed("Thread created successfully.");
						this.saveState();
						this.getThreads();
					}else{
						this.feed("Failed to create thread: " + data.error?.message || "Unknown error", true);
					}
				},
				error: (xhr, status, error) => {
					this.feed("Create thread failed. You may not be able to create threads in this channel.",true);
				}
			});
		});
		$('#composer-container').empty().append(createThreadForm);
		render_bsi();
		createThreadForm.fadeIn(300,()=>{
			$('#thread_content').focus();
		})
		
		// add my nft to my channels
		const serverImgClone = $('#server_link_img').clone();
		serverImgClone.prop('id','not_server_link_img').removeAttr('width').removeAttr('height').css({
			width: '1.6em',
			maxWidth: '1.6em',
			height: '1.6em',
			maxHeight: '1.6em'
		});
		$('#my_server_channels_img').empty().append(serverImgClone);
		// If a channel is selected, fetch all threads for this channel.
		const thread_channel_hidden = $('#thread_channel');
		if(thread_channel_hidden.length > 0){ // channel is selected, get threads.
			try{
				const apiKey = this.getSetting('server_api_key') || null;
				const serverURL = this.getSetting('server_url') || null;
				const slash = serverURL.endsWith('/') ? '' : '/';
				const endpoint = `${serverURL}${slash}api/threads_in_channel?channel_id=${thread_channel_hidden.val()}`;
				fetch(endpoint, {
					method: 'GET',
					headers: {
						'Content-Type': 'application/json',
						'Authorization': `Bearer ${apiKey}`
					}
				}).then(response => {
					if(!response.ok) {
						response.text().then(text => {
							console.error('Fetch response not ok text:', text);
						});
						throw new Error(`Server responded with status ${response.status}`);
					}
					return response.json();
				}).then(data => {
					if(!data || typeof data != 'object' || !Array.isArray(data.threads)){
						console.error("Invalid response from server.");
						return;
					}
					const threadCount = data.threads.length;
					var newThreads = 0;
					if(threadCount > 1){		
						// loop through and find if any new threads are here.
						for(let i=0; i<data.threads.length; i++){
							if(data.threads[i]?.chat_id * 1 > this.state.highest_thread_id){
								newThreads++;
							}
						}
					}
					$('#channel-thread-indicator').empty().append(
						(threadCount > 99? '99+': threadCount),
						'<br>', 
						(newThreads? `<strong style="color:rgb(255,100,100);font-size:1.6em;">${newThreads}</strong>`: '<span class="text-muted">' + bsi('arrow-up-short') + '</span>')
					);
					this.state.channelThreadsCache = JSON.parse(JSON.stringify(data.threads));
					this.saveState();
				}).catch(error => {
					console.error('Error fetching threads:', error);
				});
			}catch(e){
				this.feed("Error preparing to fetch threads from server.", true);
			}
		}
		
		// Add channel threads listener
		$('#channel-thread-indicator').on('click',(e)=>{
			e.preventDefault();
			if(!Array.isArray(this.state.channelThreadsCache) || this.state.channelThreadsCache < 1){
				return;
			}
			if($('#channel_thread_container').hasClass('showing')){
				$('#channel_thread_container').removeClass('showing').slideUp(300);
				$('.gui_blur').removeClass('gui_blur');
				return;
			}

			// search
			const channelThreadSearch = $(`<input type="text" id="channel_thread_search_input" class="form-control" placeholder="Search this channel..." style="margin-top:4px;">`);
			const channelThreadClose = $(`<a class="text-muted small float-end close_channel_thread_container"><span class="bi bi-x-lg"></span>&nbsp;close</a>`);
			const channelName = $(e.currentTarget).attr('data-name') || "Threads";
			$('#channel_thread_container').empty().append(
				`<span class="reverse-text-gradient small">${channelName}</span>`,
				channelThreadClose,
				channelThreadSearch
			);
			channelThreadSearch.on('input', (e) => {
				const searchTerm = $(e.currentTarget).val().toLowerCase().trim();
				if (!searchTerm || searchTerm.length < 1) {
					// show all threads
					$('#channel_thread_container').find('.thread').show();
				} else {
					$('#channel_thread_container').find('.thread').each((i, threadDiv) => {
						var threadTxt = $(threadDiv).text().toLowerCase();
						// replace all dashes, underscores, and spaces with single space for matching
						threadTxt = threadTxt.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');
						if (threadTxt.includes(searchTerm)) {
							$(threadDiv).show();
						} else {
							$(threadDiv).hide();
						}
					});
				}
			});
			channelThreadClose.on('click',function(){
				$('#channel-thread-indicator').trigger('click');
			});
			$('#thread_channel_selector_container').removeClass('showing').css({display:'none'});
			const thread_ids = [];
			for(let i=0; i<this.state.channelThreadsCache.length; i++){
				const thread = this.state.channelThreadsCache[i];
				// channels intentionally left blank as this is redundant.
				$('#channel_thread_container').append(`
					<div class="thread-container thread" 
						data-chat-id="${thread.chat_id}"
						data-author-alias="${(thread?.author_alias || '')}" 
						data-author-pic="${(thread?.author_pic || '')}"
						data-author-public-key="${(thread?.author_public_key || '')}"
						data-author-display-name="${(thread?.author_display_name || '')}" 
						data-content="${(thread?.content || '')}"
						data-url="${(thread?.url || '')}" 
						data-channel=""
						data-channel-id=""
						data-thread-type="${(thread?.thread_type || '')}"
						data-datetime-created="${(thread?.datetime_created || '').replace('+00:00', 'Z')}"
						data-reply-count="${(thread?.reply_count || 0)}" style="display:none;">
					</div>
				`);
				thread_ids.push(thread.chat_id*1);
			}
			const max_thread_id = Math.max(...thread_ids);
			this.state.highest_thread_id = max_thread_id > this.state.highest_thread_id? max_thread_id: this.state.highest_thread_id;
			this.saveState();
			setTimeout(()=>{
				render_threads(this.getSetting('server_url'));
				$('#reply-container').addClass('gui_blur');
				$('#channel_thread_container').find('.thread').fadeIn(300,render_bsi);
				$('#channel_thread_container').find('.thread-link-toggle').fadeIn(300);
				$('#channel_thread_container').addClass('showing').css({display:'block',height:'1vh'}).animate({height:'60vh'},300,()=>{
					$('#channel_thread_search_input').focus();
				});
			},50);
		});
	}

	getThreads(url = null) {
		if(url){ 
			// url passed means first time load for specific url, check if redundant
			const previousURL = this.getCurrentURL();
			if(previousURL && previousURL === url){
				return; // redundant, do nothing
			}
		} // else, no url passed, means reload for current url
		if (url) this.setCurrentURL(url); // will ignore non-https urls
		if (this.lockedThreadId) return; // User does not want auto reloading on navigation.
		this.emptyContainers();
		this.unloadThread();
		$('#thread_locker').css({display:'none'});
		url = this.getCurrentURL();
		// If the url is about blank or similar, ignore it
		if(
			url && (
				url.toLowerCase() === 'about:blank' || 
				url.toLowerCase().startsWith('chrome://') || 
				url.toLowerCase().startsWith('edge://') || 
				url.toLowerCase().startsWith('brave://') || 
				url.toLowerCase().startsWith('opera://') || 
				url.toLowerCase().startsWith('safari://') || 
				url.toLowerCase().startsWith('moz-extension://') || 
				url.toLowerCase().startsWith('chrome-extension://') || 
				url.toLowerCase().startsWith('file://') || 
				url.toLowerCase().startsWith('view-source:') ||  
				url.toLowerCase().startsWith('data:')
			)
		){
			return;
		}
		const apiKey = this.getSetting('server_api_key') || null;
		const serverURL = this.getSetting('server_url') || null;
		if(!serverURL || typeof serverURL != 'string' || serverURL.length < 1){
			this.feed("Set Server URL in extension settings.", true);
			return;
		}
		if(!apiKey || typeof apiKey != 'string' || apiKey.length < 1){
			this.preKeySplash(serverURL);
			return;
		}
		// Try to fetch threads using GET request to server + Authorization header
		try{
			const slash = serverURL.endsWith('/') ? '' : '/';
			const endpoint = `${serverURL}${slash}api/threads_at_url?url=${encodeURIComponent(url)}`;
			fetch(endpoint, {
				method: 'GET',
				headers: {
					'Content-Type': 'application/json',
					'Authorization': `Bearer ${apiKey}`
				}
			}).then(response => {
				if(!response.ok) {
					response.text().then(text => {
						console.error('Fetch response not ok text:', text);
					});
					throw new Error(`Server responded with status ${response.status}`);
				}
				return response.json();
			}).then(data => {
				$('#reply-container').empty();
				if(!data || typeof data != 'object' || !Array.isArray(data.threads)){
					this.feed("Invalid response from server.", true);
					return;
				}
				const threadCount = data.threads.length;
				this.feed(`${threadCount} thread${threadCount != 1 ? 's' : ''} at this URL.`);
				if(threadCount > 1){		
					const threadSearch = $(`
						<input type="text" id="thread_search_input" class="form-control" placeholder="Search threads...">
					`);
					threadSearch.on('input', (e) => {
						
						// Patch to remove render_threads() link and prevent the url ending up in the search.
						$('#reply-container').find('.thread-link-container').remove();

						const searchTerm = $(e.currentTarget).val().toLowerCase().trim();
						if (!searchTerm || searchTerm.length < 1) {
							// show all threads
							$('#reply-container').find('.thread').show();
						} else {
							$('#reply-container').find('.thread').each((i, threadDiv) => {
								var threadTxt = $(threadDiv).text().toLowerCase();
								// replace all dashes, underscores, and spaces with single space for matching
								threadTxt = threadTxt.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');
								if (threadTxt.includes(searchTerm)) {
									$(threadDiv).show();
								} else {
									$(threadDiv).hide();
								}
							});
						}
					});
					$('#reply-container').append(threadSearch);
				}

				// sort threads by id ASC
				data.threads.sort( ( a, b ) => a.chat_id < b.chat_id );
				const thread_ids = [];
				for(let i=0; i<data.threads.length; i++){
					const thread = data.threads[i];
					$('#reply-container').append(`
						<div class="thread-container thread" 
							data-chat-id="${thread.chat_id}"
							data-author-alias="${(thread?.author_alias || '')}" 
							data-author-pic="${(thread?.author_pic || '')}"
							data-author-public-key="${(thread?.author_public_key || '')}"
							data-author-display-name="${(thread?.author_display_name || '')}" 
							data-content="${(thread?.content || '')}"
							data-url="${(thread?.url || '')}" 
							data-channel="${(thread?.channel_name || '')}"
							data-channel-id="${(thread?.channel_id || '')}" 
							data-thread-type="${(thread?.thread_type || '')}"
							data-datetime-created="${(thread?.datetime_created || '').replace('+00:00', 'Z')}"
							data-reply-count="${(thread?.reply_count || 0)}" style="display:none;">
						</div>
					`);
					thread_ids.push(thread.chat_id*1);
				}
				const max_thread_id = Math.max(...thread_ids);
				this.state.highest_thread_id = max_thread_id > this.state.highest_thread_id? max_thread_id: this.state.highest_thread_id;
				this.saveState();
				setTimeout(()=>{
					render_bsi();
					render_threads(serverURL);
				},50);
				$('.thread').fadeIn(500);

				// Thread compose form
				this.buildCreateThreadForm();
			}).catch(error => {
				console.error('Error fetching threads:', error);
				this.feed("Error fetching threads from server.", true);
			});
		}catch(e){
			this.feed("Error preparing to fetch threads from server.", true);
			$('#thread-container').empty();
			return;
		}
	}

	authenticate(){
		// Attempt to fetch my public key from the server (associated with API key)
		try{
			const serverURL = this.getSetting('server_url') || null;

			// Handy place to updated the notifications opener url upon loadState.
			if($('#notifications_opener').length){
				if (serverURL && typeof serverURL == 'string' && serverURL.length > 0) {
					$('#notifications_opener').prop('href', `${serverURL}/notifications`).attr('target', '_blank');
					$('#notifications_opener').off();
				} else {
					$('#notifications_opener').prop('href', '#').attr('target', '_self');
					$('#notifications_opener').on('click',(e)=>{
						e.preventDefault();
						this.feed("Notifications endpoint not set.",true);
					});
				}
			}

			const apiKey = this.getSetting('server_api_key') || null;
			if (!serverURL || typeof serverURL != 'string' || serverURL.length < 1) return;
			if (!apiKey || typeof apiKey != 'string' || apiKey.length < 1) return;
			const slash = serverURL.endsWith('/') ? '' : '/';
			const endpoint = `${serverURL}${slash}auth/me`;
			fetch(endpoint, {
				method: 'GET',
				headers: {
					'Content-Type': 'application/json',
					'Authorization': `Bearer ${apiKey}`
				}
			}).then(response => {
				if (!response.ok) {
					throw new Error(`Server responded with status ${response.status}`);
				}
				return response.json();
			}).then(data => {
				if(!data || typeof data != 'object'){
					throw new Error('Invalid response data');
				}
				if(!data.ok || !data.authenticated){
					throw new Error('Authentication failed');
				}
				if(!data.public_key || typeof data.public_key != 'string'){
					throw new Error('No public key in response');
				}
				// Functional state user parameters
				this.publicKey = data.public_key;
				this.slug = data?.slug || null;
				this.userFiat = data?.user_fiat || null;
				this.subscribedTo = data?.subscribed_to || null;
				this.subscribedTo = Array.isArray(this.subscribedTo)? this.subscribedTo: null;
				if(this.userFiat && typeof this.userFiat == 'string'){
					$('#user_preferred_fiat').val(this.userFiat);
				}else{
					$('#user_preferred_fiat').val('');
				}
				this.myChannels = Array.isArray(data?.channels) ? data.channels : null;
				// Display user information. 
				const {display_name, current_alias, profile_picture_url} = data?.user_info || {};
				if(display_name){
					const shortened_name = display_name.length > 12? display_name.substring(0,11) + '...': display_name;
					$('#main-logo').empty().append(shortened_name);
				}
				const serverURL = this.getSetting('server_url') || null;
				if(serverURL && typeof serverURL == 'string'){
					const slash = serverURL && serverURL.endsWith('/') ? '' : '/';
					const slug = (current_alias && typeof current_alias == 'string')? current_alias: this.publicKey;
					const profile = `${serverURL}${slash}profile/${slug}`;
					$('#server_link').prop('href',profile);
				}
				if(profile_picture_url && typeof profile_picture_url == 'string' && profile_picture_url.toLowerCase().startsWith('https://')){
					$('#server_link_img').attr('src', profile_picture_url).css({borderRadius:'50%'});
				}
				// call buildCreateThreadForm() if create thread form exists.
				if($('#create_thread_form').length) {
					this.buildCreateThreadForm(false);
				}
			}).catch(error => {
				console.error('Error fetching user public key using api key. ', error);
			});
		}catch(e){
			console.error('Error fetching user public key using api key. ', e);
		}
	}

	checkForNotifications() {
		// Fetch count of unread notifications from server
		try {
			const serverURL = this.getSetting('server_url') || null;
			const apiKey = this.getSetting('server_api_key') || null;
			if (!serverURL || typeof serverURL != 'string' || serverURL.length < 1) return;
			if (!apiKey || typeof apiKey != 'string' || apiKey.length < 1) return;
			const slash = serverURL.endsWith('/') ? '' : '/';
			const endpoint = `${serverURL}${slash}api/notification-count`;
			fetch(endpoint, {
				method: 'GET',
				headers: {
					'Content-Type': 'application/json',
					'Authorization': `Bearer ${apiKey}`
				}
			}).then(response => {
				if (!response.ok) {
					throw new Error(`Server responded with status ${response.status}`);
				}
				return response.json();
			}).then(data => {
				if (!data || typeof data != 'object') {
					throw new Error('Invalid response data');
				}
				if (!data.ok) {
					throw new Error('Failed to fetch notification count');
				}
				const count = data?.count || 0;
				$('#notifications_ticker').empty().append(count > 0 ? `${count}` : '');
				if (count > 0) {
					$('#notifications_opener').addClass('notification-unread');
					$('#notifications_opener').attr('title', `You have ${count} unread notification${count != 1 ? 's' : ''}. Click to view.`);
				} else {
					$('#notifications_opener').removeClass('notification-unread');
					$('#notifications_opener').attr('title', 'No unread notifications.');
				}
			}).catch(error => {
				console.error('Error fetching notification count:', error);
			});
		} catch (e) {
			console.error('Error fetching notification count:', e);
		}
	}

	loadState() {
		chrome.storage.local.get(['settings', 'thread_sort_mode', 'last_channel_id', 'highest_thread_id'], (result) => {
			if (chrome.runtime.lastError) {
				console.error('Error loading state:', chrome.runtime.lastError);
				return;
			}
			this.state.settings = result?.settings || {};
			this.state.thread_sort_mode = result?.thread_sort_mode || 'date_desc'; // default to newest sort mode
			this.state.last_channel_id = result?.last_channel_id || null;
			this.state.highest_thread_id = result?.highest_thread_id || 0;
			// Ensure all default settings are present if missing
			for (let key in this.settingsDefault) {
				if (!(key in this.state.settings)) {
					this.state.settings[key] = this.settingsDefault[key];
				}
			}
			const apiKey = this.getSetting('server_api_key') || null;
			if(apiKey && typeof apiKey == 'string' && apiKey.length > 0) {
				localStorage.setItem('jwt', apiKey); // Used by render_threads.js and follow.js
			}
			this.applyFontSizeSetting();
			this.authenticate();
		});
	}

	saveState() {
		chrome.storage.local.set({
			settings: this.state?.settings || {},
			thread_sort_mode: this.state?.thread_sort_mode || 'date_desc',
			last_channel_id: this.state?.last_channel_id || null,
			highest_thread_id: this.state?.highest_thread_id || 0
		}, () => {
			if (chrome.runtime.lastError) {
				console.error('Error saving state:', chrome.runtime.lastError);
			}
		});
	}

	applyFontSizeSetting() { 
		const font_size = this.getSetting('font_size');
		if (!font_size || typeof font_size != 'string' || font_size.length < 1) return;
		// validate that the font size ends with em and is a number from 0.5 to 1.5
		const font_size_num = parseFloat(font_size.replace('em', ''));
		if (isNaN(font_size_num) || font_size_num < 0.5 || font_size_num > 1.5 || !font_size.endsWith('em')) return;
		$('body').css({ fontSize: font_size });
	}

	getSetting(key) {
		if ('settings' in this.state && this.state.settings && typeof this.state.settings == 'object' && key in this.state.settings) {
			return this.state.settings[key];
		}
		return this.settingsDefault[key] || null;
	}

	updateSettings(newSettings) {
		let validSettings = {};
		let invalidParams = [];
		for (let key in newSettings) {
			if (this.settingsSchema[key]) {
				if (typeof newSettings[key] === this.settingsSchema[key]) {
					validSettings[key] = newSettings[key];
				} else if (this.settingsSchema[key] === 'number' && !isNaN(newSettings[key] * 1)) {
					validSettings[key] = newSettings[key] * 1;
				} else if (this.settingsSchema[key] === 'boolean' && ["true", "false"].indexOf(newSettings[key].toString().toLowerCase()) > -1) {
					validSettings[key] = newSettings[key].toString().toLowerCase() === 'true' ? true : false;
				} else {
					invalidParams.push(key);
				}
			} else {
				invalidParams.push(key);
			}
		}

		if (invalidParams.length > 0) {
			const invStr = invalidParams.join(', ');
			this.feed(`Invalid setting or type for parameter(s): ${invStr}`, true);
		} else {
			// merge partial settings with existing settings
			this.state.settings = { ...this.state.settings, ...validSettings };
			this.saveState();
			this.feed("Settings updated.")
		}
	}

	buildSettingsForm() {
		this.emptyContainers();
		this.unloadThread();
		$('#reply-container').empty().append('<h2>Extension Settings</h2>');

		// Create cancel button
		const cancelIcon = bsi('x-lg') || '❌';
		const cancelLink = $(`<a href="#" class="btn btn-sm btn-secondary float-end" id="exit_settings">${cancelIcon} Cancel</a>`);
		cancelLink.on('click', e => {
			e.preventDefault();
			this.getThreads();
		});
		$('#reply-container').append(cancelLink, '<br><br>');

		const settingsForm = $(`<form></form>`);
		for (var key in this.settingsDefault) {
			var input = null;
			var label = `<label for="${key}" title="${(this.settingsDescriptions[key] || '')}">${key.replace(/_/g, ' ').toUpperCase()}</label>`;
			if (key == 'font_size') { // font size dropdown
				input = $(`<select name="font_size"></select>`);
				const options = ['0.5em', '0.6em', '0.7em', '0.8em', '0.9em', '1em', '1.1em', '1.2em', '1.3em', '1.4em', '1.5em'];
				const dflt = this.state.settings?.[key] || this.settingsDefault?.[key] || null;
				for (var j = 0; j < options.length; j++) {
					const opt = options[j];
					const selected = dflt == opt ? ' selected' : '';
					input.append(`<option value="${opt}"${selected}>${opt}</option>`);
				}
			} else {
				const typ = typeof this.settingsDefault[key] === 'number' ? 'number' : 'text';
				const val = this.state.settings?.[key] || this.settingsDefault[key];
				input = $(`<input type="${typ}" title="${(this.settingsDescriptions[key] || '')}" name="${key}" value="${val}">`);
			}
			if (!input) continue; // skip if no input
			if (label) settingsForm.append(label, '<br>');
			input.addClass('form-control');
			settingsForm.append(input, '<br>');
		}

		// append submit button
		settingsForm.append(`<br><br><input type="submit" value="Save Settings" class="btn-primary form-control"><br><br>`);
		settingsForm.on('submit', (e) => {
			e.preventDefault();
			for (var key in this.settingsDefault) {
				const input = settingsForm.find(`[name="${key}"]`);
				if (input.length < 1) continue;
				const val = input.val();
				if (Array.isArray(this.settingsDefault[key])) {
					const checkedBoxes = input.find('input[type="checkbox"]:checked');
					const checkedValues = [];
					checkedBoxes.each((i, el) => {
						checkedValues.push($(el).val());
					});
					this.updateSettings({ [key]: checkedValues });
				} else if (this.settingsSchema?.[key] == 'boolean') {
					this.updateSettings({ [key]: input.is(':checked') });
				} else {
					this.updateSettings({ [key]: val });
				}
			}
			this.saveState();
			this.applyFontSizeSetting();
			this.loadState(); // re-authenticate if api key or server url changed
			$('#exit_settings').trigger('click');
		});
		$('#reply-container').append(settingsForm);

		// Display extension version info
		if (this.version && typeof this.version == 'string') {
			$('#reply-container').append(`<div class="text-muted small" style="margin-top:10px;">Extension Version: ${this.version}</div>`);
		}else{
			$('#reply-container').append(`<div class="text-muted small" style="margin-top:10px;">Extension Version: Unknown</div>`);
		}
	}
}
